리액트에서 "리렌더링 방지"는 과연 중요할까?
2025-05-25
리액트 성능 최적화를 이야기할 때 가장 자주 언급되는 것 중 하나가 바로 "불필요한 리렌더링을 방지하자"는 것입니다. useCallback
, useMemo
, React.memo
와 같은 도구들을 사용해 컴포넌트가 다시 렌더링되는 것을 막으려고 노력하죠. 하지만 정말로 이것이 가장 중요한 최적화일까요?
리렌더링에 대한 오해
많은 개발자들이 useCallback
을 이용한 최적화가 필수라고 생각합니다. props로 함수를 넘길 때 매번 새로운 함수가 생성되어 자식 컴포넌트가 리렌더링되는 것을 막기 위해서죠.
// "최적화"를 위해 useCallback 사용
const handleClick = useCallback(() => {
// 클릭 핸들러 로직
}, []);
return <ChildComponent onClick={handleClick} />;
하지만 useCallback
은 React의 렌더 단계(Render Phase)에서의 최적화입니다. 즉, 컴포넌트가 다시 실행되고 가상 DOM을 비교하는 과정에서 불필요한 작업을 줄이는 것이죠. 실제 DOM을 업데이트하는 커밋 단계나 브라우저 렌더링과는 관련이 없습니다.
그런데 앞서 봤듯이 렌더 단계는 이미 충분히 빠릅니다. 그렇다면 정말로 useCallback
에 그렇게 집착해야 할까요?
React는 실제로 어떻게 동작하는가?
Virtual DOM이 하는 역할을 한 문장으로 정의하면 "DOM을 업데이트하는 것은 느리니, DOM 업데이트를 묶어서 한번에 처리하자"입니다.
React의 동작 과정을 살펴보면:
render → reconciliation → commit
↖ ↙
state change
- 렌더(Render) 단계: React 함수를 호출해 React 엘리먼트를 생성
- 조정(Reconciliation) 단계: 이전 엘리먼트와 새로운 엘리먼트를 비교
- 커밋(Commit) 단계: 실제 DOM을 업데이트
React와 브라우저 렌더링의 전체 과정
하지만 실제로는 React의 처리만으로 끝나지 않습니다. 브라우저가 화면에 최종 결과를 보여주기까지의 전체 과정을 살펴보면:
[React Render Phase] → [React Commit Phase] → [브라우저 Layout (Reflow)] → [브라우저 Paint] → [브라우저 Composite]
1. React Render Phase
- React가 상태/props 변화 감지
- 컴포넌트 함수 실행 (함수형 컴포넌트 본문)
- 가상 DOM 생성 및 비교 (diffing)
- 변경 사항 파악 (어떤 DOM을 업데이트할지 계산)
- Side Effect는 실행하지 않음
2. React Commit Phase
- 실제 DOM 업데이트
- DOM 변경 반영 (삽입, 제거, 수정 등)
useLayoutEffect
,componentDidMount
,componentDidUpdate
실행- 이 시점부터 브라우저가 DOM을 "본격적으로" 인식 가능
3. 브라우저 Reflow (Layout)
- DOM 변경 감지
- 레이아웃 계산 (요소 크기, 위치 등)
offsetHeight
,getBoundingClientRect()
등 접근 가능해짐
4. 브라우저 Repaint
- 스타일 계산 (color, background 등)
- 픽셀 단위로 화면에 그리기 시작
5. 브라우저 Composite
- 여러 레이어를 하나로 합성
- 최종 렌더링 결과를 화면에 표시
여기서 중요한 점은 useLayoutEffect
는 브라우저가 레이아웃 계산하기 직전에 실행되고, useEffect
는 브라우저 렌더링 완료 후 실행된다는 것입니다.
진짜 느린 부분은 어디일까?
전체 과정을 보면 가장 느린 부분은 브라우저의 "Layout(Reflow)" 단계입니다.
"DOM 업데이트가 느리다"고 할 때, 실제로는 두 가지 종류의 작업이 있습니다:
빠른 DOM 작업들:
- 텍스트 내용 변경 (
element.textContent = "새 텍스트"
) - 이벤트 리스너 추가/제거
- 클래스명 변경
- 기본적인 DOM 노드 생성/삭제
느린 DOM 작업들:
- 레이아웃에 영향을 주는 변경사항 (크기, 위치, 여백 등)
offsetHeight
,getBoundingClientRect()
같은 레이아웃 정보 조회- 많은 수의 DOM 노드를 한번에 조작
예를 들어보겠습니다:
// 빠른 작업 - 브라우저가 레이아웃을 다시 계산하지 않음
element.style.color = "red"; // 빠름
element.textContent = "새 내용"; // 빠름
// 느린 작업 - 브라우저가 레이아웃을 다시 계산해야 함
element.style.width = "200px"; // 느림 (레이아웃 변경)
element.style.height = "100px"; // 느림 (레이아웃 변경)
const height = element.offsetHeight; // 느림 (레이아웃 정보 조회)
따라서 더 정확히 말하면:
- React의 커밋 단계 자체는 그리 느리지 않습니다
- 브라우저가 레이아웃을 다시 계산하는 과정이 느립니다
React는 DOM을 효율적으로 업데이트하지만, 레이아웃에 영향을 주는 변경사항이 많다면 결국 브라우저가 무거운 계산을 해야 하므로 느려질 수밖에 없습니다.
불필요한 리렌더링의 진실
간단한 예제를 보겠습니다:
function Foo() {
return <div>FOO!</div>;
}
function Counter() {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<>
<Foo />
<button onClick={increment}>{count}</button>
</>
);
}
버튼을 클릭할 때마다 Foo
컴포넌트가 다시 실행됩니다. 하지만 <div>FOO!</div>
는 이전과 동일하므로 실제 DOM은 업데이트되지 않습니다. React의 조정(reconciliation) 과정에서 "변경사항이 없다"고 판단하기 때문이죠.
이런 경우를 "불필요한 리렌더링"이라고 부릅니다. 컴포넌트는 다시 실행되었지만 실제 화면에는 아무 변화가 없는 상황이죠.
핵심은 이렇습니다: 컴포넌트가 다시 렌더링된다고 해서 DOM이 반드시 업데이트되는 것은 아닙니다.
진짜 문제: 느린 렌더링
JavaScript는 객체 생성(렌더 단계)과 비교 작업(조정 단계)을 매우 빠르게 처리합니다. 구형 모바일 기기에서도 말이죠.
그런데 왜 앱이 버벅거릴까요?
렌더링 과정에서 실행되는 코드 자체가 문제일 가능성이 큽니다. 예를 들어:
function SlowComponent({ items }) {
// 😱 렌더링할 때마다 무거운 계산 실행
const expensiveValue = items
.map((item) => someComplexCalculation(item))
.filter((result) => result > threshold)
.sort((a, b) => b - a);
return <div>{expensiveValue.length} items processed</div>;
}
이런 코드가 있다면 컴포넌트가 렌더링될 때마다 복잡한 계산이 실행됩니다. 리렌더링 횟수를 줄이는 것보다 이 계산 자체를 최적화하는 것이 훨씬 효과적이죠.
Kent C. Dodds는 이를 재미있게 비유합니다:
눈을 깜빡일 때마다 자신의 얼굴을 때린다고 상상해보세요. 그럼 "눈을 덜 깜빡여야겠다"고 생각하시겠어요? 아니면 "눈을 깜빡일 때마다 얼굴을 때리는 것을 멈춰야겠다"고 생각하시겠어요?
느린 렌더링 문제를 해결하지 않고 단순히 렌더링 횟수만 줄이는 것은 근본적인 해결책이 아닙니다. 오히려 코드만 복잡해지고 나중에 더 큰 문제가 될 수 있어요.
올바른 접근 방법
1단계: 느린 렌더링부터 해결하세요
- 브라우저 개발자 도구의 Performance 탭을 사용해서 어떤 코드가 오래 걸리는지 찾아보세요
- React DevTools Profiler로 어떤 컴포넌트가 느린지 확인하세요
- 렌더 함수에서 무거운 작업들을 제거하거나 최적화하세요
// ❌ 나쁜 예: 렌더링할 때마다 계산
function BadComponent({ data }) {
const result = data.map((item) => expensiveCalculation(item));
return <div>{result.length}</div>;
}
// ✅ 좋은 예: useMemo로 계산 결과 캐싱
function GoodComponent({ data }) {
const result = useMemo(
() => data.map((item) => expensiveCalculation(item)),
[data]
);
return <div>{result.length}</div>;
}
2단계: 그 다음에 불필요한 리렌더링을 고려하세요
느린 렌더링을 해결한 후에도 성능 문제가 있다면, 그때 useCallback
, useMemo
, React.memo
등을 고려해보세요. 하지만 실제 측정 결과를 바탕으로 판단하는 것이 중요합니다.
결론
100% 필요한 렌더링이라도 그 렌더링이 느리다면 사용자 경험은 나빠집니다.
눈을 깜빡일 때마다 얼굴을 때리는 것을 멈추세요. 먼저 느린 렌더링을 수정하고, 그 다음에 "불필요한 리렌더링"을 다루세요.
성능 최적화의 핵심은 측정 가능한 데이터를 바탕으로 실제 병목 지점을 해결하는 것입니다. 막연한 추측이나 과도한 최적화보다는, 진짜 문제가 무엇인지 파악하고 그것부터 해결하는 것이 더 효과적입니다.
useCallback
이나 React.memo
같은 최적화 도구들이 나쁜 것은 아닙니다. 하지만 순서가 중요합니다. 렌더링 자체가 느린 상황에서 리렌더링 횟수만 줄이는 것은 임시방편일 뿐이에요.
이 글은 Kent C. Dodds의 "Fix the slow render before you fix the re-render"를 참고하여 작성되었습니다.